import { useEffect, useMemo, useRef, useState } from 'react'
import { request } from '../lib/http'
import useThemeStore from '../app/store/themeStore'

type GraphNode = { id: number; type: string; label: string; props: Record<string, unknown> }
type GraphEdge = { id: number; type: string; src: number; dst: number; props: Record<string, unknown> }

type GraphResponse = {
  nodes: GraphNode[]
  edges: GraphEdge[]
  now: string
  filters: { types?: string[] | null; include_expired: boolean; upcoming_days: number }
}

type RGB = [number, number, number]

// 类型中文映射
const TYPE_LABEL_ZH: Record<string, string> = {
  person: '人物',
  preference: '偏好',
  topic: '话题',
  reminder: '提醒',
  agreement: '约定',
  boundary: '边界',
  anniversary: '纪念日',
  category: '类别',
}

const EDGE_LABEL_ZH: Record<string, string> = {
  prefers: '喜欢',
  avoids: '不喜欢',
  mentions: '提及',
  reminds: '提醒',
  agreed_with: '约定',
  has_boundary: '边界',
  has_anniversary: '纪念日',
  partner_of: '伴侣关系',
  belongs_to: '属于类别',
  has_category: '拥有类别',
  relates_to: '关联',
}

const clamp = (value: number) => Math.min(255, Math.max(0, Math.round(value)))

const parseRgbTuple = (value: string | undefined): RGB | null => {
  if (!value) return null
  const raw = value.trim()
  if (!raw) return null
  if (raw.startsWith('#')) {
    const hex = raw.replace('#', '')
    if (hex.length === 3) {
      const r = parseInt(hex[0] + hex[0], 16)
      const g = parseInt(hex[1] + hex[1], 16)
      const b = parseInt(hex[2] + hex[2], 16)
      return [r, g, b]
    }
    if (hex.length === 6) {
      const r = parseInt(hex.slice(0, 2), 16)
      const g = parseInt(hex.slice(2, 4), 16)
      const b = parseInt(hex.slice(4, 6), 16)
      return [r, g, b]
    }
    return null
  }
  const cleaned = raw.replace(/^rgba?\(/, '').replace(/\)$/, '')
  const parts = cleaned.split(',').map((p) => Number(p.trim()))
  if (parts.length >= 3 && parts.every((p) => Number.isFinite(p))) {
    return [clamp(parts[0]), clamp(parts[1]), clamp(parts[2])]
  }
  return null
}

const tupleToHex = ([r, g, b]: RGB) => `#${clamp(r).toString(16).padStart(2, '0')}${clamp(g).toString(16).padStart(2, '0')}${clamp(b).toString(16).padStart(2, '0')}`

const tupleToRgba = ([r, g, b]: RGB, alpha = 1) => `rgba(${clamp(r)}, ${clamp(g)}, ${clamp(b)}, ${alpha})`

const mixTuple = (a: RGB, b: RGB, weight: number): RGB => {
  const w = Math.min(1, Math.max(0, weight))
  return [
    clamp(a[0] * (1 - w) + b[0] * w),
    clamp(a[1] * (1 - w) + b[1] * w),
    clamp(a[2] * (1 - w) + b[2] * w),
  ]
}

const lightenTuple = (tuple: RGB, amount: number) => mixTuple(tuple, [255, 255, 255], amount)
const darkenTuple = (tuple: RGB, amount: number) => mixTuple(tuple, [0, 0, 0], amount)

const ensureTuple = (tuple: RGB | null | undefined, fallback: RGB): RGB => tuple ?? fallback

const PATH_HIGHLIGHT_COLOR = '#ef4444'
const PATH_HIGHLIGHT_FILL = 'rgba(239, 68, 68, 0.12)'
const DEFAULT_NODE_COLOR = '#94a3b8'
const DEFAULT_NODE_FILL = 'rgba(148, 163, 184, 0.2)'

// 小图例组件：圆点 + 文本
type LegendStyleProps = {
  background: string
  border: string
  text: string
  dotOutline: string
}

const LegendDot = ({ color, label, style }: { color: string; label: string; style: LegendStyleProps }) => (
  <span
    className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 backdrop-blur-sm"
    style={{ backgroundColor: style.background, borderColor: style.border, color: style.text }}
  >
    <span
      className="h-2 w-2 rounded-full"
      style={{ backgroundColor: color, boxShadow: `0 0 0 1px ${style.dotOutline}` }}
    />
    <span className="text-[11px]" style={{ color: style.text }}>
      {label}
    </span>
  </span>
)

// 边图例组件：彩色线段 + 文本
const LegendEdge = ({ color, label, style }: { color: string; label: string; style: LegendStyleProps }) => (
  <span
    className="inline-flex items-center gap-2 rounded-full border px-2 py-0.5 backdrop-blur-sm"
    style={{ backgroundColor: style.background, borderColor: style.border, color: style.text }}
  >
    <span className="relative h-[2px] w-6 rounded-full" style={{ backgroundColor: color }}>
      <span
        className="absolute -right-1 -top-[3px] h-2 w-2 rounded-full"
        style={{ backgroundColor: color, boxShadow: `0 0 0 1px ${style.dotOutline}` }}
      />
    </span>
    <span className="text-[11px]" style={{ color: style.text }}>
      {label}
    </span>
  </span>
)

const MemoryGraphPage = () => {
  const themeMode = useThemeStore((s) => s.mode)
  if (typeof document !== 'undefined') document.title = '记忆图谱 · Love Notes'
  const isVibrantMode = themeMode === 'vibrant'
  const palette = useThemeStore((s) => s.palette)
  const [nodes, setNodes] = useState<GraphNode[]>([])
  const [edges, setEdges] = useState<GraphEdge[]>([])
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)
  const [selected, setSelected] = useState<GraphNode | null>(null)
  const graphRef = useRef<any>(null)
  const [ForceGraph2D, setForceGraph2D] = useState<any>(null)
  const [pathStartId, setPathStartId] = useState<number | null>(null)
  const [pathTargetId, setPathTargetId] = useState<number | null>(null)
  const [pathNodeSet, setPathNodeSet] = useState<Set<number>>(new Set())
  const [pathEdgeSet, setPathEdgeSet] = useState<Set<number>>(new Set())
  // 自适应容器尺寸（确保画布不挤压导航/在移动端也适配）
  const containerRef = useRef<HTMLDivElement | null>(null)
  const [dims, setDims] = useState<{ w: number; h: number }>({ w: 800, h: 480 })
  const graphTokens = useMemo(() => {
    const fallbackPalette: RGB[] = [
      [56, 189, 248],
      [99, 102, 241],
      [34, 197, 94],
      [251, 146, 60],
    ]
    const paletteValues: string[] = Array.isArray(palette) ? [...palette] : []
    const safePalette =
      paletteValues.length === fallbackPalette.length
        ? paletteValues
        : fallbackPalette.map((tuple) => tuple.join(','))
    const accentTuples = safePalette.map((value, index) =>
      ensureTuple(parseRgbTuple(value), fallbackPalette[index]),
    ) as RGB[]
    const [accent1, accent2, accent3, accent4] = accentTuples
    const rose: RGB = [244, 63, 94]
    const slate: RGB = [148, 163, 184]
    const cyan: RGB = [14, 165, 233]
    const isVibrant = themeMode === 'vibrant'

    const prefLike: RGB = accent3
    const prefDislike: RGB = mixTuple(rose, accent4, 0.35)
    const graphBackground: RGB = isVibrant ? [15, 22, 40] : [245, 247, 255]
    const strokeMixWeight = isVibrant ? 0.6 : 0.18
    const fillMixWeight = isVibrant ? 0.48 : 0.58
    const fillAlpha = isVibrant ? 0.28 : 0.45
    const controlBlendTarget: RGB = isVibrant ? mixTuple(graphBackground, [255, 255, 255], 0.14) : [255, 255, 255]

    const tintStroke = (tuple: RGB) => mixTuple(tuple, graphBackground, strokeMixWeight)
    const strokeHex = (tuple: RGB) => tupleToHex(tintStroke(tuple))
    const fillTuple = (tuple: RGB) => mixTuple(tintStroke(tuple), controlBlendTarget, fillMixWeight)
    const fillRgba = (tuple: RGB) => tupleToRgba(fillTuple(tuple), fillAlpha)

    const nodeBase: Record<string, RGB> = {
      person: accent1,
      preference: prefLike,
      topic: mixTuple(accent2, accent1, 0.35),
      reminder: accent4,
      agreement: mixTuple(accent2, rose, 0.45),
      boundary: mixTuple(rose, accent1, 0.25),
      anniversary: mixTuple(accent2, accent1, 0.28),
      category: mixTuple(accent1, cyan, 0.45),
    }

    const nodeColors: Record<string, string> = {}
    const nodeFills: Record<string, string> = {}
    for (const [key, tuple] of Object.entries(nodeBase)) {
      nodeColors[key] = strokeHex(tuple)
      nodeFills[key] = fillRgba(tuple)
    }
    nodeColors.preference_like = strokeHex(prefLike)
    nodeColors.preference_dislike = strokeHex(prefDislike)
    nodeFills.preference_like = fillRgba(prefLike)
    nodeFills.preference_dislike = fillRgba(prefDislike)
    nodeColors.default = strokeHex(slate)
    nodeFills.default = fillRgba(slate)

    const edgeBase: Record<string, RGB> = {
      prefers: prefLike,
      avoids: prefDislike,
      mentions: mixTuple(slate, nodeBase.topic, 0.45),
      reminds: nodeBase.reminder,
      agreed_with: nodeBase.agreement,
      has_boundary: nodeBase.boundary,
      has_anniversary: nodeBase.anniversary,
      partner_of: mixTuple(accent1, accent2, 0.45),
      belongs_to: nodeBase.category,
      has_category: mixTuple(nodeBase.category, cyan, 0.35),
      relates_to: mixTuple(slate, accent1, 0.35),
    }

    const edgeColors = Object.fromEntries(
      Object.entries(edgeBase).map(([key, tuple]) => [key, strokeHex(tuple)]),
    ) as Record<string, string>

    const legendStyle: LegendStyleProps = isVibrant
      ? {
          background: 'rgba(16, 24, 44, 0.78)',
          border: 'rgba(86, 102, 180, 0.4)',
          text: 'rgba(226, 232, 240, 0.82)',
          dotOutline: 'rgba(9, 13, 26, 0.85)',
        }
      : {
          background: 'rgba(255, 255, 255, 0.86)',
          border: 'rgba(148, 163, 184, 0.3)',
          text: '#475569',
          dotOutline: 'rgba(226, 232, 240, 0.9)',
        }

    const badgeStyles = Object.fromEntries(
      Object.entries(nodeBase).map(([key, tuple]) => {
        const stroke = tintStroke(tuple)
        const backgroundTuple = fillTuple(tuple)
        const background = tupleToRgba(backgroundTuple, isVibrant ? 0.34 : 0.46)
        const border = tupleToRgba(stroke, isVibrant ? 0.55 : 0.42)
        const text = tupleToHex(
          isVibrant ? lightenTuple(tuple, 0.68) : darkenTuple(tuple, 0.3),
        )
        const shadow = `${isVibrant ? '0 22px 48px -32px' : '0 16px 30px -24px'} ${tupleToRgba(
          tintStroke(tuple),
          isVibrant ? 0.5 : 0.26,
        )}`
        return [
          key,
          {
            background,
            border,
            text,
            shadow,
          },
        ]
      }),
    ) as Record<string, { background: string; border: string; text: string; shadow: string }>

    const defaultBadge = {
      background: tupleToRgba(fillTuple(slate), isVibrant ? 0.3 : 0.42),
      border: tupleToRgba(tintStroke(slate), isVibrant ? 0.5 : 0.32),
      text: tupleToHex(isVibrant ? lightenTuple(slate, 0.65) : darkenTuple(slate, 0.4)),
      shadow: `${isVibrant ? '0 18px 40px -28px' : '0 12px 24px -20px'} ${tupleToRgba(
        tintStroke(slate),
        isVibrant ? 0.42 : 0.18,
      )}`,
    }

    return {
      nodeColors,
      nodeFills,
      edgeColors,
      legendStyle,
      badgeStyles,
      defaultBadge,
      prefColors: {
        like: strokeHex(prefLike),
        dislike: strokeHex(prefDislike),
      },
      prefFills: {
        like: fillRgba(prefLike),
        dislike: fillRgba(prefDislike),
      },
      canvasBackground: isVibrant ? 'rgba(10, 16, 30, 0.92)' : 'rgba(248, 250, 255, 0.98)',
      nodeLabelColor: isVibrant ? 'rgba(230, 235, 245, 0.92)' : '#334155',
    }
  }, [palette, themeMode])

  const fetchGraph = async (): Promise<GraphResponse | null> => {
    setLoading(true)
    setError(null)
    try {
      const data = await request<GraphResponse>('/ai/graph')
      setNodes(data.nodes)
      setEdges(data.edges)
      return data
    } catch (e) {
      setError(e instanceof Error ? e.message : '加载图谱失败')
      return null
    } finally {
      setLoading(false)
    }
  }

  useEffect(() => {
    void fetchGraph()
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  useEffect(() => {
    if (!selected) {
      setPathStartId(null)
      setPathTargetId(null)
      setPathNodeSet(new Set())
      setPathEdgeSet(new Set())
      return
    }
    setPathStartId(null)
    setPathTargetId(null)
    setPathNodeSet(new Set())
    setPathEdgeSet(new Set())
  }, [selected])

  useEffect(() => {
    // 动态加载 2D 力导图组件（优先 react-force-graph-2d，失败则回退 react-force-graph）
    ;(async () => {
      try {
        const mod = await import('react-force-graph-2d')
        // 默认导出或命名导出兼容
        setForceGraph2D(() => (mod as any).default || (mod as any).ForceGraph2D)
        return
      } catch (e) {
        // fallback
      }
      try {
        const mod2 = await import('react-force-graph')
        setForceGraph2D(() => (mod2 as any).ForceGraph2D || (mod2 as any).default)
      } catch (e) {
        // 仍失败则保持简版分组列表
      }
    })()
  }, [])

  useEffect(() => {
    const el = containerRef.current
    if (!el) return
    const resize = () => {
      const rect = el.getBoundingClientRect()
      const styles = window.getComputedStyle(el)
      const padX = parseFloat(styles.paddingLeft) + parseFloat(styles.paddingRight)
      const padY = parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom)
      const w = Math.max(300, rect.width - padX)
      const h = Math.max(240, rect.height - padY)
      setDims({ w, h })
    }
    resize()
    const ro = new ResizeObserver(resize)
    ro.observe(el)
    window.addEventListener('resize', resize)
    return () => {
      ro.disconnect()
      window.removeEventListener('resize', resize)
    }
  }, [])

  const degree = useMemo(() => {
    const d: Record<number, number> = {}
    for (const e of edges) {
      d[e.src] = (d[e.src] ?? 0) + 1
      d[e.dst] = (d[e.dst] ?? 0) + 1
    }
    return d
  }, [edges])
  const nowTs = useMemo(() => Date.now(), [])
  const recency = useMemo(() => {
    const r: Record<number, number> = {}
    for (const n of nodes) {
      if (n.type === 'reminder' && typeof n.props?.due === 'string') {
        const t = Date.parse(n.props.due as string)
        if (!Number.isNaN(t)) {
          const diff = Math.abs(t - nowTs)
          // 7 天内最高权重，越近越大，最低 1
          const weight = Math.max(1, Math.round(10 - Math.min(diff / (1000 * 60 * 60 * 24), 7)))
          r[n.id] = weight
        }
      } else if (n.type === 'anniversary' && typeof n.props?.date === 'string') {
        const t = Date.parse(n.props.date as string)
        if (!Number.isNaN(t)) {
          const diffDays = Math.max(0, Math.round((t - nowTs) / (1000 * 60 * 60 * 24)))
          // 30 天内越近权重越高
          const weight = Math.max(1, 8 - Math.min(diffDays, 30) / 5)
          r[n.id] = Math.round(weight)
        }
      }
    }
    return r
  }, [nodes, nowTs])

  // 计算最短路径（无权重，BFS）
  const adjacency = useMemo(() => {
    const map = new Map<number, number[]>()
    for (const n of nodes) map.set(n.id, [])
    for (const e of edges) {
      if (map.has(e.src)) map.get(e.src)!.push(e.dst)
      if (map.has(e.dst)) map.get(e.dst)!.push(e.src)
    }
    return map
  }, [nodes, edges])

  // 偏好节点极性（喜欢/不喜欢）
  const prefPolarity = useMemo(() => {
    const m: Record<number, 'like' | 'dislike' | undefined> = {}
    for (const e of edges) {
      if (e.type === 'prefers' || e.type === 'avoids') {
        const dst = nodes.find((n) => n.id === e.dst)
        if (dst && dst.type === 'preference') {
          m[dst.id] = e.type === 'prefers' ? 'like' : 'dislike'
        }
      }
    }
    return m
  }, [edges, nodes])

  const computeShortestPath = (fromId: number, toId: number) => {
    const prev = new Map<number, number | null>()
    const q: number[] = []
    q.push(fromId)
    prev.set(fromId, null)
    while (q.length) {
      const v = q.shift()!
      if (v === toId) break
      for (const w of adjacency.get(v) || []) {
        if (!prev.has(w)) {
          prev.set(w, v)
          q.push(w)
        }
      }
    }
    if (!prev.has(toId)) return { nodes: new Set<number>(), edges: new Set<number>() }
    const pathNodes: number[] = []
    let cur: number | null = toId
    while (cur !== null) {
      pathNodes.push(cur)
      cur = prev.get(cur) ?? null
    }
    pathNodes.reverse()
    const nodeSet = new Set(pathNodes)
    const edgeSet = new Set<number>()
    for (let i = 0; i < pathNodes.length - 1; i++) {
      const a = pathNodes[i], b = pathNodes[i + 1]
      const e = edges.find((ee) => (ee.src === a && ee.dst === b) || (ee.src === b && ee.dst === a))
      if (e) edgeSet.add(e.id)
    }
    return { nodes: nodeSet, edges: edgeSet }
  }

  useEffect(() => {
    if (!selected) return
    if (!pathTargetId) {
      setPathNodeSet(new Set())
      setPathEdgeSet(new Set())
      return
    }
    const start = pathStartId ?? selected.id
    if (!start) return
    const { nodes: ns, edges: es } = computeShortestPath(start, pathTargetId)
    setPathNodeSet(new Set(ns))
    setPathEdgeSet(new Set(es))
  }, [pathStartId, pathTargetId, selected, adjacency])

  useEffect(() => {
    const fg = graphRef.current
    if (!fg || typeof fg.d3Force !== 'function') return
    const chargeForce = fg.d3Force('charge')
    if (chargeForce && typeof chargeForce.strength === 'function') {
      chargeForce.strength(-180)
      if (typeof chargeForce.distanceMin === 'function') chargeForce.distanceMin(40)
      if (typeof chargeForce.distanceMax === 'function') chargeForce.distanceMax(520)
    }
    const linkForce = fg.d3Force('link')
    if (linkForce && typeof linkForce.distance === 'function') {
      linkForce.distance(140)
      if (typeof linkForce.strength === 'function') linkForce.strength(0.6)
    }
    if (typeof fg.cooldownTicks === 'function') {
      fg.cooldownTicks(150)
    }
    if (typeof fg.d3Alpha === 'function') fg.d3Alpha(1)
    if (typeof fg.d3ReheatSimulation === 'function') fg.d3ReheatSimulation()
  }, [nodes, edges, ForceGraph2D])

  useEffect(() => {
    const fg = graphRef.current
    if (!fg || typeof fg.zoomToFit !== 'function') return
    const timer = window.setTimeout(() => {
      fg.zoomToFit(420)
    }, 360)
    return () => window.clearTimeout(timer)
  }, [nodes, edges])

  useEffect(() => {
    graphRef.current?.refresh?.()
  }, [pathNodeSet, pathEdgeSet])

  const activeBadgeTone =
    selected && graphTokens.badgeStyles[selected.type]
      ? graphTokens.badgeStyles[selected.type]
      : graphTokens.defaultBadge

  return (
    <div className="space-y-4">
      {loading ? (
        <p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>
          加载中…
        </p>
      ) : null}
      {error ? <p className="text-sm text-rose-500">{error}</p> : null}

      <div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
        <section
          ref={containerRef}
          className="relative h-[60vh] overflow-hidden rounded-2xl p-2 sm:h-[66vh] lg:h-[72vh]"
          style={{
            background: 'var(--surface-primary)',
            border: '1px solid var(--border-soft)',
            boxShadow: 'var(--shadow-soft)',
            color: 'var(--text-primary)',
          }}
        >
          {/* 图例 */}
          <div
            className="pointer-events-auto absolute left-2 top-2 z-10 flex max-w-[90%] flex-col gap-2 rounded-xl border p-2 text-[11px] backdrop-blur-sm"
            style={{
              backgroundColor: graphTokens.legendStyle.background,
              borderColor: graphTokens.legendStyle.border,
              color: graphTokens.legendStyle.text,
            }}
          >
            <div className="flex flex-wrap gap-2">
              <LegendDot color={graphTokens.nodeColors.person} label="我/伴侣" style={graphTokens.legendStyle} />
              <LegendDot color={graphTokens.nodeColors.preference_like} label="喜欢" style={graphTokens.legendStyle} />
              <LegendDot
                color={graphTokens.nodeColors.preference_dislike}
                label="不喜欢"
                style={graphTokens.legendStyle}
              />
              <LegendDot color={graphTokens.nodeColors.topic} label="话题" style={graphTokens.legendStyle} />
              <LegendDot color={graphTokens.nodeColors.reminder} label="提醒" style={graphTokens.legendStyle} />
              <LegendDot color={graphTokens.nodeColors.agreement} label="约定" style={graphTokens.legendStyle} />
              <LegendDot color={graphTokens.nodeColors.boundary} label="边界" style={graphTokens.legendStyle} />
              <LegendDot color={graphTokens.nodeColors.anniversary} label="纪念日" style={graphTokens.legendStyle} />
            </div>
            <div className="flex flex-wrap gap-2">
              <LegendEdge color={graphTokens.edgeColors.prefers} label={EDGE_LABEL_ZH.prefers} style={graphTokens.legendStyle} />
              <LegendEdge color={graphTokens.edgeColors.avoids} label={EDGE_LABEL_ZH.avoids} style={graphTokens.legendStyle} />
              <LegendEdge color={graphTokens.edgeColors.mentions} label={EDGE_LABEL_ZH.mentions} style={graphTokens.legendStyle} />
              <LegendEdge color={graphTokens.edgeColors.reminds} label={EDGE_LABEL_ZH.reminds} style={graphTokens.legendStyle} />
              <LegendEdge color={graphTokens.edgeColors.agreed_with} label={EDGE_LABEL_ZH.agreed_with} style={graphTokens.legendStyle} />
              <LegendEdge color={graphTokens.edgeColors.has_boundary} label={EDGE_LABEL_ZH.has_boundary} style={graphTokens.legendStyle} />
              <LegendEdge
                color={graphTokens.edgeColors.has_anniversary}
                label={EDGE_LABEL_ZH.has_anniversary}
                style={graphTokens.legendStyle}
              />
              <LegendEdge color={graphTokens.edgeColors.partner_of} label={EDGE_LABEL_ZH.partner_of} style={graphTokens.legendStyle} />
              <LegendEdge color={graphTokens.edgeColors.belongs_to} label={EDGE_LABEL_ZH.belongs_to} style={graphTokens.legendStyle} />
              <LegendEdge
                color={graphTokens.edgeColors.has_category}
                label={EDGE_LABEL_ZH.has_category}
                style={graphTokens.legendStyle}
              />
              <LegendEdge color={graphTokens.edgeColors.relates_to} label={EDGE_LABEL_ZH.relates_to} style={graphTokens.legendStyle} />
            </div>
          </div>
          {ForceGraph2D ? (
            <ForceGraph2D
              ref={graphRef}
              graphData={{
                nodes: nodes.map((n) => ({ id: n.id, name: n.label, val: Math.max(1, (degree[n.id] ?? 1) + (recency[n.id] ?? 0)), type: n.type, raw: n })),
                links: edges.map((e) => ({ source: e.src, target: e.dst, raw: e })),
              }}
              width={dims.w}
              height={dims.h}
              backgroundColor={graphTokens.canvasBackground}
              warmupTicks={80}
              cooldownTicks={160}
              linkDistance={(link: any) => {
                const t = link.raw?.type
                switch (t) {
                  case 'has_category':
                    return 60
                  case 'belongs_to':
                    return 70
                  case 'relates_to':
                    return 90
                  case 'prefers':
                  case 'avoids':
                    return 150
                  case 'mentions':
                    return 140
                  case 'reminds':
                    return 150
                  case 'has_anniversary':
                    return 180
                  case 'partner_of':
                    return 80
                  default:
                    return 120
                }
              }}
              linkWidth={(link: any) => (pathEdgeSet.has(link.raw.id) ? 2.4 : 1)}
              linkDirectionalParticles={(link: any) => (pathEdgeSet.has(link.raw.id) ? 2 : 0)}
              linkDirectionalParticleWidth={2.5}
              linkDirectionalParticleSpeed={0.006}
              // 节点/边颜色：类型 + 偏好极性 + 路径高亮
              nodeColor={(node: any) => {
                if (pathNodeSet.has(node.id)) return PATH_HIGHLIGHT_COLOR
                if (node.type === 'preference') {
                  const pol = prefPolarity[node.id]
                  return pol === 'dislike' ? graphTokens.prefColors.dislike : graphTokens.prefColors.like
                }
                return graphTokens.nodeColors[node.type] || graphTokens.nodeColors.default
              }}
              linkColor={(link: any) =>
                pathEdgeSet.has(link.raw.id)
                  ? PATH_HIGHLIGHT_COLOR
                  : graphTokens.edgeColors[link.raw.type] || graphTokens.edgeColors.relates_to || DEFAULT_NODE_COLOR
              }
              nodeRelSize={6}
              nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
                const label = node.name as string
                const fontSize = Math.max(10, 12 / globalScale)
                ctx.font = `${fontSize}px ui-sans-serif, -apple-system, system-ui, Segoe UI, Roboto`
                const paddingX = 10
                const paddingY = 6
                const textWidth = ctx.measureText(label).width
                const w = textWidth + paddingX * 2
                const h = fontSize + paddingY * 2
                const x = node.x - w / 2
                const y = node.y - h / 2
                // card background with border
                const isHL = pathNodeSet.has(node.id)
                const borderColor = isHL
                  ? PATH_HIGHLIGHT_COLOR
                  : node.type === 'preference'
                    ? prefPolarity[node.id] === 'dislike'
                      ? graphTokens.prefColors.dislike
                      : graphTokens.prefColors.like
                    : graphTokens.nodeColors[node.type] || graphTokens.nodeColors.default
                const fillColor = isHL
                  ? PATH_HIGHLIGHT_FILL
                  : node.type === 'preference'
                    ? prefPolarity[node.id] === 'dislike'
                      ? graphTokens.prefFills.dislike
                      : graphTokens.prefFills.like
                    : graphTokens.nodeFills[node.type] || graphTokens.nodeFills.default
                ctx.fillStyle = fillColor
                ctx.strokeStyle = borderColor
                ctx.lineWidth = isHL ? 2 : 1
                const r = 6
                // rounded rect
                ctx.beginPath()
                ctx.moveTo(x + r, y)
                ctx.lineTo(x + w - r, y)
                ctx.quadraticCurveTo(x + w, y, x + w, y + r)
                ctx.lineTo(x + w, y + h - r)
                ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
                ctx.lineTo(x + r, y + h)
                ctx.quadraticCurveTo(x, y + h, x, y + h - r)
                ctx.lineTo(x, y + r)
                ctx.quadraticCurveTo(x, y, x + r, y)
                ctx.closePath()
                ctx.fill()
                ctx.stroke()
                // glow for highlight
                if (isHL) {
                  ctx.save()
                  ctx.shadowColor = 'rgba(239,68,68,0.6)'
                  ctx.shadowBlur = 12
                  ctx.strokeStyle = PATH_HIGHLIGHT_COLOR
                  ctx.stroke()
                  ctx.restore()
                }
                // days badge for anniversaries
                if (node.type === 'anniversary') {
                  const raw = node.raw || {}
                  const du = typeof raw?.props?.days_until === 'number' ? Number(raw.props.days_until) : NaN
                  if (!Number.isNaN(du)) {
                    const badgeText = du <= 0 ? '今天' : `${du}天`
                    const bFont = Math.max(9, 11 / globalScale)
                    ctx.font = `${bFont}px ui-sans-serif, -apple-system, system-ui, Segoe UI, Roboto`
                    const tw = ctx.measureText(badgeText).width
                    const bxPad = 6
                    const byPad = 2
                    const bw = tw + bxPad * 2
                    const bh = bFont + byPad * 2
                    const bx = x + w - bw - 6
                    const by = y + 4
                    ctx.beginPath()
                    const br = 6
                    ctx.moveTo(bx + br, by)
                    ctx.lineTo(bx + bw - br, by)
                    ctx.quadraticCurveTo(bx + bw, by, bx + bw, by + br)
                    ctx.lineTo(bx + bw, by + bh - br)
                    ctx.quadraticCurveTo(bx + bw, by + bh, bx + bw - br, by + bh)
                    ctx.lineTo(bx + br, by + bh)
                    ctx.quadraticCurveTo(bx, by + bh, bx, by + bh - br)
                    ctx.lineTo(bx, by + br)
                    ctx.quadraticCurveTo(bx, by, bx + br, by)
                    ctx.closePath()
                    const badgeFill = graphTokens.nodeFills.anniversary ?? 'rgba(99,102,241,0.18)'
                    const badgeStroke = graphTokens.nodeColors.anniversary ?? '#6366f1'
                    ctx.fillStyle = badgeFill
                    ctx.strokeStyle = badgeStroke
                    ctx.lineWidth = 1
                    ctx.fill()
                    ctx.stroke()
                    ctx.fillStyle = isVibrantMode ? 'rgba(226,232,240,0.9)' : '#3730a3'
                    ctx.textAlign = 'left'
                    ctx.textBaseline = 'middle'
                    ctx.fillText(badgeText, bx + bxPad, by + bh / 2)
                    // restore font for main label
                    ctx.font = `${fontSize}px ui-sans-serif, -apple-system, system-ui, Segoe UI, Roboto`
                    ctx.strokeStyle = borderColor
                    ctx.fillStyle = fillColor
                  }
                }
                // type dot
                ctx.fillStyle = borderColor
                ctx.beginPath()
                ctx.arc(x + 8, y + h / 2, 3, 0, 2 * Math.PI)
                ctx.fill()
                // text
                const labelColor = isHL ? PATH_HIGHLIGHT_COLOR : graphTokens.nodeLabelColor
                ctx.fillStyle = labelColor
                ctx.textAlign = 'left'
                ctx.textBaseline = 'middle'
                ctx.fillText(label, x + 16, y + h / 2)
                ;(node as any).__bckgDimensions = [w, h]
              }}
              nodePointerAreaPaint={(node: any, color: string, ctx: CanvasRenderingContext2D) => {
                const b = node.__bckgDimensions
                if (!b) return
                ctx.fillStyle = color
                ctx.fillRect(node.x - b[0] / 2, node.y - b[1] / 2, b[0], b[1])
              }}
              onEngineStop={() => {
                try {
                  graphRef.current?.zoomToFit?.(400)
                } catch {
                  // ignore zoom errors
                }
              }}
              onNodeClick={(node: any) => setSelected(node.raw as GraphNode)}
            />
          ) : (
            <p
              className="p-4 text-sm"
              style={{ color: 'var(--text-secondary)' }}
            >
              未安装 react-force-graph，显示简版分组列表。
            </p>
          )}
        </section>

        <aside
          className="rounded-2xl p-4"
          style={{
            background: 'var(--surface-elevated)',
            border: '1px solid var(--border-soft)',
            boxShadow: 'var(--shadow-soft)',
            color: 'var(--text-secondary)',
          }}
        >
          <h3 className="mb-2 text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
            详情
          </h3>
          {!selected ? (
            <p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
              点击左侧节点查看详情与操作
            </p>
          ) : (
            <div className="space-y-2 text-xs">
              <div
                className="inline-flex items-center gap-2 rounded-full border px-2 py-1"
                style={{
                  backgroundColor: activeBadgeTone.background,
                  borderColor: activeBadgeTone.border,
                  color: activeBadgeTone.text,
                  boxShadow: activeBadgeTone.shadow,
                }}
              >
                <span className="font-semibold">
                  {TYPE_LABEL_ZH[selected.type] ?? selected.type}
                </span>
              </div>
              <div className="">
                <div style={{ color: 'var(--text-tertiary)' }}>标签</div>
                <div style={{ color: 'var(--text-primary)' }}>{selected.label}</div>
              </div>
              {selected.type === 'anniversary' ? (
                <div
                  className="rounded-2xl border p-2"
                  style={{
                    background: 'var(--surface-muted)',
                    borderColor: 'var(--border-soft)',
                    color: 'var(--text-secondary)',
                  }}
                >
                  <div className="mb-1" style={{ color: 'var(--text-tertiary)' }}>
                    纪念日
                  </div>
                  <div className="text-[12px]">
                    日期：{typeof selected.props?.date === 'string' ? String(selected.props.date) : '-'}
                  </div>
                  {typeof selected.props?.days_until === 'number' ? (
                    <div className="text-[12px]">还有 {Number(selected.props.days_until)} 天</div>
                  ) : null}
                </div>
              ) : null}
              {/* 路径高亮 */}
              <div className="space-y-2">
                <div style={{ color: 'var(--text-tertiary)' }}>最短路径高亮</div>
                <select
                  className="w-full rounded-xl px-2 py-2 text-xs focus:outline-none focus:ring-2 focus:ring-[rgba(148,163,184,0.32)]"
                  style={{
                    background: 'var(--surface-muted)',
                    border: '1px solid var(--border-soft)',
                    color: 'var(--text-secondary)',
                  }}
                  value={pathStartId ?? selected.id}
                  onChange={(e) => {
                    const v = e.target.value ? Number(e.target.value) : null
                    setPathStartId(v)
                  }}
                >
                  <option value={selected.id}>起点：当前选中</option>
                  {nodes
                    .filter((n) => n.id !== selected.id)
                    .slice(0, 500)
                    .map((n) => (
                      <option key={n.id} value={n.id}>
                        [{TYPE_LABEL_ZH[n.type] ?? n.type}] {n.label}
                      </option>
                    ))}
                </select>
                <select
                  className="w-full rounded-xl px-2 py-2 text-xs focus:outline-none focus:ring-2 focus:ring-[rgba(148,163,184,0.32)]"
                  style={{
                    background: 'var(--surface-muted)',
                    border: '1px solid var(--border-soft)',
                    color: 'var(--text-secondary)',
                  }}
                  value={pathTargetId ?? ''}
                  onChange={(e) => {
                    const v = e.target.value ? Number(e.target.value) : null
                    setPathTargetId(v)
                  }}
                >
                  <option value="">终点：选择目标节点</option>
                  {nodes
                    .filter((n) => n.id !== selected.id)
                    .slice(0, 500)
                    .map((n) => (
                      <option key={n.id} value={n.id}>
                        [{TYPE_LABEL_ZH[n.type] ?? n.type}] {n.label}
                      </option>
                    ))}
                </select>
              </div>
              {selected.type === 'reminder' ? (
                <ReminderActions
                  node={selected}
                  onChanged={async (hint?: { rid?: string; remove?: boolean }) => {
                    const res = await fetchGraph()
                    if (!res) return
                    // reselect updated/deleted node
                    if (hint?.remove) {
                      setSelected(null)
                      return
                    }
                    const rid = hint?.rid
                    if (rid) {
                      const found = res.nodes.find((n) => n.type === 'reminder' && typeof n.props?.id === 'string' && (n.props as any).id === rid)
                      if (found) setSelected(found)
                    }
                  }}
                />
              ) : null}
              {/* 相关连接 */}
              <RelatedEdges node={selected} nodes={nodes} edges={edges} />
              <div>
                <div style={{ color: 'var(--text-tertiary)' }}>原始属性</div>
                <pre
                  className="max-h-48 overflow-auto rounded p-2 text-[11px]"
                  style={{
                    background: 'var(--surface-muted)',
                    border: '1px solid var(--border-soft)',
                    color: 'var(--text-secondary)',
                  }}
                >
                  {JSON.stringify(selected.props, null, 2)}
                </pre>
              </div>
            </div>
          )}
        </aside>
      </div>
    </div>
  )
}

const ReminderActions = ({ node, onChanged }: { node: GraphNode; onChanged: (hint?: { rid?: string; remove?: boolean }) => Promise<void> }) => {
  const [due, setDue] = useState<string>(typeof node.props?.due === 'string' ? (node.props.due as string) : '')
  const [pending, setPending] = useState(false)
  const rid = (node.props?.id as string) || String(node.id)

  const doDone = async () => {
    setPending(true)
    try {
      const res = await request(`/ai/memory/reminders/${rid}/done`, { method: 'POST' })
      await onChanged({ rid })
    } finally {
      setPending(false)
    }
  }
  const doSnooze = async () => {
    if (!due) return
    setPending(true)
    try {
      await request(`/ai/memory/reminders/${rid}/snooze`, { method: 'POST', body: { due } })
      await onChanged({ rid })
    } finally {
      setPending(false)
    }
  }
  const doDelete = async () => {
    setPending(true)
    try {
      await request(`/ai/memory/reminders/${rid}`, { method: 'DELETE' })
      await onChanged({ rid, remove: true })
    } finally {
      setPending(false)
    }
  }

  return (
    <div
      className="rounded-2xl border p-2"
      style={{
        background: 'var(--surface-muted)',
        border: '1px solid var(--border-soft)',
        color: 'var(--text-secondary)',
      }}
    >
      <div className="mb-2" style={{ color: 'var(--text-tertiary)' }}>
        提醒操作
      </div>
      <div className="mb-2 flex items-center gap-2">
        <input
          className="flex-1 rounded-xl px-2 py-2 text-xs focus:outline-none focus:ring-2 focus:ring-[rgba(148,163,184,0.32)]"
          style={{
            background: 'var(--surface-primary)',
            border: '1px solid var(--border-soft)',
            color: 'var(--text-secondary)',
          }}
          placeholder="新的到期时间 ISO8601 (例如 2025-01-06T09:30:00+08:00)"
          value={due}
          onChange={(e) => setDue(e.target.value)}
        />
        <button disabled={pending} onClick={doSnooze} className="rounded bg-amber-500 px-3 py-1 text-white disabled:opacity-60">延期</button>
      </div>
      <div className="flex items-center gap-2">
        <button disabled={pending} onClick={doDone} className="rounded bg-emerald-500 px-3 py-1 text-white disabled:opacity-60">完成</button>
        <button disabled={pending} onClick={doDelete} className="rounded bg-rose-500 px-3 py-1 text-white disabled:opacity-60">删除</button>
      </div>
    </div>
  )
}

const RelatedEdges = ({ node, nodes, edges }: { node: GraphNode; nodes: GraphNode[]; edges: GraphEdge[] }) => {
  const neighbors = useMemo(() => {
    const id = node.id
    const out: { type: string; to: GraphNode; edge: GraphEdge }[] = []
    for (const e of edges) {
      if (e.src === id) {
        const n = nodes.find((x) => x.id === e.dst)
        if (n) out.push({ type: e.type, to: n, edge: e })
      } else if (e.dst === id) {
        const n = nodes.find((x) => x.id === e.src)
        if (n) out.push({ type: e.type, to: n, edge: e })
      }
    }
    return out
  }, [node, nodes, edges])

  if (!neighbors.length) return null
  return (
    <div className="space-y-1">
      <div style={{ color: 'var(--text-tertiary)' }}>相关连接</div>
      <ul className="space-y-1">
        {neighbors.map((it) => (
          <li
            key={`${it.edge.id}`}
            className="flex items-center justify-between rounded px-3 py-2"
            style={{
              background: 'var(--surface-muted)',
              border: '1px solid var(--border-soft)',
              color: 'var(--text-secondary)',
            }}
          >
            <span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
              {EDGE_LABEL_ZH[it.type] ?? it.type}
            </span>
            <span className="text-[11px]" style={{ color: 'var(--text-secondary)' }}>
              [{TYPE_LABEL_ZH[it.to.type] ?? it.to.type}] {it.to.label}
            </span>
          </li>
        ))}
      </ul>
    </div>
  )
}

export default MemoryGraphPage
